fix(sanctions): pass timeRange filter to sanctions pressure API#2455
fix(sanctions): pass timeRange filter to sanctions pressure API#2455Jayanth-reflex wants to merge 13 commits intokoala73:mainfrom
Conversation
The sanctions panel was ignoring the user-selected timeRange filter, causing newEntryCount to always show 0. The API endpoint never received the timeRange parameter, so it could not filter entries by their effectiveAt timestamp. Changes: - Add time_range query param to ListSanctionsPressureRequest proto - Update generated client/server to pass and parse time_range - Server handler now recomputes isNew, newEntryCount, and per-country/ per-program counts based on the requested time window - Client service accepts and forwards timeRange to the API - Data loader passes ctx.currentTimeRange to fetchSanctionsPressure - Panel layout triggers sanctions reload on timeRange change Fixes koala73#2437 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@Jayanth-reflex is attempting to deploy a commit to the Elie Team on Vercel. A member of the Team first needs to authorize it. |
Greptile SummaryThis PR fixes the root cause of the always-zero
Confidence Score: 4/5The server-side filtering logic is sound, but two client-side caching bugs will cause incorrect data to be displayed when switching time ranges — the feature's primary user path. There are two P1 defects in sanctions-pressure.ts: (1) the circuit breaker cache is not keyed on timeRange, so switching windows silently serves stale data from the previous window; (2) bootstrap hydrated data bypasses the timeRange filter on first render. Both directly break the core behaviour this PR is meant to fix. Score 4 to indicate these should be resolved before merging. src/services/sanctions-pressure.ts — circuit breaker cacheKey and hydration bypass both need fixes here. Important Files Changed
|
…ntryCount The previous approach built countryMap/programMap by iterating entries, which silently dropped countries or programs not referenced by any entry's countryCodes/programs arrays. Now we start from the original data.countries and data.programs arrays and only patch newEntryCount, guaranteeing every item from the seed dataset is preserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Good catch — fixed in b92b46d. The approach now starts from Before (buggy): After (fixed): Same pattern applied to programs. |
…n when filtered Two P1 client-side bugs: 1. Cache keyed on timeRange: breaker.execute() was called without a cacheKey, so all time-range variants shared the same 30-min cache slot. Switching from '7d' to '1h' silently returned stale '7d' data. Fix: pass cacheKey = timeRange || 'all' so each window has its own slot, and forward the same key to breaker.clearCache(). 2. Bootstrap hydration bypasses timeRange: getHydratedData returns the seed script's static isNew flags and cannot be re-filtered. With a non-default timeRange the hydrated path was returning incorrect counts on initial render. Fix: skip the hydration path entirely when a timeRange is set, falling through to the circuit-breaker path which sends timeRange to the server and gets correctly filtered data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Both P1 issues fixed in 41a24a8: 1. Circuit breaker cache keyed on Each distinct window now gets its own cache slot: const cacheKey = timeRange || 'all';
return breaker.execute(fn, emptyResult, {
cacheKey,
shouldCache: (result) => result.totalCount > 0,
});
2. Bootstrap hydration bypasses The hydration path is now skipped when a if (!timeRange) {
const hydrated = getHydratedData('sanctionsPressure') ...
if (hydrated?.entries?.length || ...) {
// only reached when no filter — static flags are valid
return toResult(hydrated);
}
}
// timeRange set → always fetch live with the filter applied |
|
@koala73 , kindly review this PR |
SebastienMelki
left a comment
There was a problem hiding this comment.
Well-reasoned fix. Author self-corrected all three Greptile P1s (circuit breaker cache key on timeRange, hydration bypass, country/program preservation). Proto → generated stubs → service → panel wiring is clean. LGTM.
|
@SebastienMelki , could you please guide me on how I can get the pending checks done |
I just launched the workflows, they should run in a few seconds |
Thanks, @SebastienMelki. I see the Vercel check is failing. Could you please suggest how we can fix this? |
|
Hi @SebastienMelki, @koala73, could you please help me with the Vercel check and retrigger the workflows, so I can merge this PR? |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
koala73
left a comment
There was a problem hiding this comment.
Code Review — sanctions timeRange filter
Good direction on this PR — the timeRange filter is the right UX improvement. A few issues to address before merge.
🔴 P1 — Date.now() frozen in CDN cache
File: server/worldmonitor/sanctions/v1/list-sanctions-pressure.ts:55
applyTimeRangeFilter calls Date.now() inside a handler registered as 'static' tier in gateway.ts (s-maxage=600, stale-while-revalidate=300). The cutoff timestamp is evaluated once at cache-fill time, then frozen for the full 900s TTL.
For the 1h window: an entry with effectiveAt = T-3500s (30s inside the window at fill time) shows isNew=true in the cached response. At T=3600s that entry is now outside the window — users continue receiving the stale isNew=true badge for up to 15 minutes.
Preferred fix (aligns with gold standard): Move applyTimeRangeFilter logic into scripts/seed-sanctions-pressure.mjs. Write separate Redis keys: sanctions:pressure:v1:1h, sanctions:pressure:v1:6h, etc. Edge handler reads sanctions:pressure:v1:${req.timeRange || ''} — no Date.now() on edge, full CDN cacheability preserved.
Minimal fix: Return no-store cache headers for ?time_range=* requests in gateway.ts. Every filtered request hits the edge but getCachedJson still coalesces Redis reads so cost is low.
🔴 P1 — timeRange required in generated interfaces
Files: src/generated/client/worldmonitor/sanctions/v1/service_client.ts:5, src/generated/server/worldmonitor/sanctions/v1/service_server.ts:5
// Current (wrong):
timeRange: string
// Fix:
timeRange?: stringProto semantics for an absent query param is an empty string default — the field is logically optional. Any call site constructing ListSanctionsPressureRequest without timeRange silently violates the contract.
Also in list-sanctions-pressure.ts:57:
// Current (dead code — effectiveAt is always string, else branch never executes):
const ts = typeof e.effectiveAt === 'string'
? Number(e.effectiveAt)
: (e.effectiveAt as unknown as number);
// Fix:
const ts = Number(e.effectiveAt);🟡 P2 — onTimeRangeChanged fires for all panels unconditionally
Files: src/App.ts, src/app/panel-layout.ts:~142
onTimeRangeChanged is wired into applyTimeRangeFilterDebounced which fires for all panel time range changes across the app. Every time range change on any panel (news, market, aviation, etc.) triggers loadSanctionsPressure() even when the sanctions panel is not mounted.
// Current:
onTimeRangeChanged: () => {
void this.dataLoader.loadSanctionsPressure();
}
// Fix:
onTimeRangeChanged: () => {
if (this.panelLayout.isPanelConnected('sanctions-pressure')) {
void this.dataLoader.loadSanctionsPressure();
}
}🟡 P2 — Shared circuit breaker failure state across all time-range slots
File: src/services/sanctions-pressure.ts, src/utils/circuit-breaker.ts
The CircuitBreaker maintains a single CircuitState (failure counter + cooldownUntil). All 6 cache keys ('all', '1h', '6h', '24h', '48h', '7d') share that one counter. Two 7d failures puts the entire circuit into 5-minute cooldown, blocking the default 'all' path used as the canonical view.
persistCache: true and 30-min TTL limit the blast radius for returning users, but fresh sessions (or after IndexedDB eviction) will see error state across all views.
Fix: Use separate new CircuitBreaker(...) instances per time range in sanctions-pressure.ts for independent failure counters.
P1 — Date.now() frozen in CDN cache: The sanctions endpoint is tier 'static' (s-maxage=600). When time_range is present, applyTimeRangeFilter calls Date.now() to compute a cutoff that becomes stale once the CDN caches the response. Fix: set X-No-Cache header on the Response when time_range is present so the gateway emits no-store instead of s-maxage=600. P1 — timeRange required in generated interfaces: Proto semantics for an absent query param is empty-string default, so the field is logically optional. Changed to timeRange?: string in both generated client and server interfaces. Also simplified dead-code branch in effectiveAt parsing (typeof check on a value that is always string from proto) to plain Number(e.effectiveAt). P2 — onTimeRangeChanged fires unconditionally: Every time-range change across all panels triggered loadSanctionsPressure(), even when the sanctions panel was not mounted. Now guarded with a panels['sanctions-pressure'] check. P2 — Shared circuit breaker failure state: All time-range cache keys shared a single CircuitState, so two failures on e.g. '7d' put the entire breaker into 5-min cooldown, blocking the canonical 'all' path. Fix: use a dedicated filteredBreaker for time-range requests with independent failure counters (shorter TTL, no persistent cache since filtered data is inherently time-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@koala73 Thanks for the thorough review. All four issues addressed in 0d24bd6: 🔴 P1 — Date.now() frozen in CDN cacheWent with the minimal fix: the generated route handler now sets if (body.timeRange) responseHeaders["X-No-Cache"] = "1";🔴 P1 — timeRange required → optionalChanged // Before (else branch never executes — effectiveAt is always string from proto):
const ts = typeof e.effectiveAt === 'string' ? Number(e.effectiveAt) : (e.effectiveAt as unknown as number);
// After:
const ts = Number(e.effectiveAt);🟡 P2 — onTimeRangeChanged fires unconditionallyNow guarded so it only fires when the sanctions panel is actually mounted: onTimeRangeChanged: () => {
if (this.state.panels['sanctions-pressure']) {
void this.dataLoader.loadSanctionsPressure();
}
},🟡 P2 — Shared circuit breaker failure stateIntroduced a dedicated const breaker = createCircuitBreaker<SanctionsPressureResult>({
name: 'Sanctions Pressure',
cacheTtlMs: 30 * 60 * 1000,
persistCache: true,
});
const filteredBreaker = createCircuitBreaker<SanctionsPressureResult>({
name: 'Sanctions Pressure (filtered)',
cacheTtlMs: 10 * 60 * 1000,
persistCache: false, // filtered data is time-sensitive, no point persisting
});Both |


Summary
time_rangequery parameter toListSanctionsPressureRequestproto and generated client/servernewEntryCount(and per-country/per-program counts) by comparing each entry'seffectiveAtagainstDate.now() - windowMs, so the "New" badge accurately reflects the user-selected time window (1h, 6h, 24h, 48h, 7d)timeRangefrom the app context to the API callRoot cause
The sanctions panel never passed
timeRangeto its API endpoint. The server returned the seed script's diff-basedisNewflags regardless of the UI filter, causingnewEntryCountto always show 0 when no entries had been added since the last seed run.Files changed
proto/.../list_sanctions_pressure.prototime_rangefield (field 2)src/generated/client/.../service_client.tstimeRangeto request interface + query param serializationsrc/generated/server/.../service_server.tstimeRangeto request interface + query param parsingserver/.../list-sanctions-pressure.tsapplyTimeRangeFilter()— recomputesisNew,newEntryCount, country & program counts based oneffectiveAtwithin the time windowsrc/services/sanctions-pressure.tsfetchSanctionsPressure()now accepts optionaltimeRangeparamsrc/app/data-loader.tsthis.ctx.currentTimeRangetofetchSanctionsPressure()src/app/panel-layout.tsonTimeRangeChangedcallback; triggers sanctions reload on range changesrc/App.tsonTimeRangeChangedtodataLoader.loadSanctionsPressure()Test plan
7d→ verify API call includes?time_range=7dnewEntryCountreflects entries witheffectiveAtwithin the last 7 days (not always 0)7dto24h→ panel reloads with updated countsallor omit → verify existing behavior (no filtering)newEntryCountbadges update correctlynpm run typecheck:all— passes cleanlyFixes #2437
🤖 Generated with Claude Code